Skip to content

fix: The MCP server's upstream call has no deadline and no error handling - #525

Merged
Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
Wittig18:solver/issue-349-25-a0
Oct 19, 2026
Merged

Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
Wittig18:solver/issue-349-25-a0

Conversation

@Wittig18

Copy link
Copy Markdown

Summary

bazaar_search in src/mcp/server.ts now has a 10s deadline, checks the response status and content type before parsing, and returns structured errors that name the upstream. The deadline is AbortSignal.timeout(10_000) passed to the fetch, as in src/alerts/delivery.ts. A non-2xx response, a response whose content-type isn't JSON, or a body that fails to parse each return an ERR_SERVICE_UNREACHABLE tool error with isError set. A timeout returns the same code with a message saying the upstream did not respond within 10000ms. Every message includes the upstream host. I also gave paid_call the same deadline and a timeout branch in handlePaidCallError. paid_call still returns non-JSON bodies as text, so it doesn't reject them. I haven't run any tests, since I can't run commands here.

Changed files

  • src/mcp/server.ts
  • tests/mcp.test.ts

Test plan

Run npx vitest run tests/mcp.test.ts. The new describe block 'bazaar_search upstream failures (#349)' covers the deadline signal being passed, a timeout, a 502 HTML response, a text/html 200, and a JSON content-type with an unparseable body. I changed the existing okFetchImpl fixture to send content-type: application/json, because without it the new content-type check would reject the existing success test. Then run the full npm test; Load test (prices) already fails on main.

This PR was created as a draft by the issue solver bot. It will remain a
draft until repository CI passes.

Closes #349

@drips-wave

drips-wave Bot commented Oct 18, 2026

Copy link
Copy Markdown

@Wittig18 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Wittig18
Wittig18 marked this pull request as ready for review October 18, 2026 23:01
@Miracle656
Miracle656 merged commit 443174e into Divinity-Labs:main Oct 19, 2026
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged. The title understates it — this fixes two distinct failure modes, and the worse one is the hang.

bazaar_search awaited ssrfSafeFetch with no deadline, so an upstream that accepts the connection and then goes quiet never returns. That's worse than an error: an MCP client gets no answer at all and no reason, and the tool call just sits there. A 10s AbortSignal.timeout is the right fix and matching the existing deadline in src/alerts/delivery.ts is the right value — one house number for outbound calls beats a new one per call site.

The second one: await res.json() on a non-JSON body threw a raw parse error at the client. Checking content-type first and still guarding the parse is the correct belt-and-braces — a server can advertise JSON and send something else, so the content-type check alone wouldn't be enough.

Distinguishing timeouts from other failures matters more than it looks. Without it, an upstream that times out reports whatever generic message happens to surface, and the operator chases a JSON problem that doesn't exist. name === 'TimeoutError' || 'AbortError' is the right test for AbortSignal.timeout.

And naming the upstream host in every message is the difference between a debuggable failure and a bare ERR_SERVICE_UNREACHABLE. I checked whether that leaks anything: the host comes from the caller's own url argument, so it's their input coming back to them. Nothing disclosed.

Two merge resolutions

Both against work that landed earlier today, neither your fault:

  1. tests/mcp.test.ts conflicted with fix(mcp): validate tool arguments with zod and constrain http methods #555's argument-validation suite. Additive on both sides — both blocks kept.
  2. fix(mcp): validate tool arguments with zod and constrain http methods #555's two paid_call tests asserted the fetch options exactly:
expect(mockFetch).toHaveBeenCalledWith('http://example.com/paid', { method: 'POST' })

Your new signal makes that exact match fail. Those tests are about the method being passed through, not about the options object being empty otherwise, so I loosened them to expect.objectContaining({ method: 'POST' }) with a comment saying why. Your change is correct; the assertion was over-tight.

Verified on the merge result rather than your branch: tsc --noEmit silent, 901 tests across 83 suites, nothing red.

Miracle656 added a commit that referenced this pull request Oct 21, 2026
…ctually use it

`handlePaidCallError` matched on message text alone — `msg.includes('trustline')`,
`msg.includes('fetch')` and so on. That misclassifies as soon as a provider
rewords an error, and it already did: a payment failure whose message contained
the word "fetch" was reported as the service being unreachable.

`classifyPaidCallError` reads structured signals first — Horizon `result_codes`,
explicit error codes — and falls back to text only when there is nothing
structured, recording which path answered. A structured match is a fact; a text
match is a guess a provider can invalidate.

Completed while merging:

- **The classifier was never called.** `grep -rn classifyPaidCallError src/`
  found it only in the file that defines it; `server.ts` imported just
  `MCP_ERROR_CODES`. So the function shipped as dead code and the behaviour the
  issue describes was unchanged. `handlePaidCallError` now delegates to it,
  keeping the timeout case first and ahead of it — an AbortError carries no
  structured signal and no useful message, so only the caller knows what it
  means.
- **Added `isFallback` to the return**, which the PR's own tests assert on and
  the implementation did not provide. Useful in its own right: a caller logging
  a classification wants to know how much to trust it.
- **The branch dropped 19 tests that exist on `main`** — the Zod argument suite
  from #555 and the deadline/content-type suite from #525, both landed after it
  branched. Its `tests/mcp.test.ts` replaced `main`'s wholesale, so git saw no
  conflict. Kept `main`'s file and appended this PR's two blocks: 37 tests.
- Fixed four `vi.fn().mockRejected(...)` / `.mockResolved(...)` calls — the
  vitest APIs are `mockRejectedValue` / `mockResolvedValue`, so those tests
  threw before any assertion ran.
- Restored the `captureHandler` helper the new tests need, and gave the four
  server constructions a `paidCallAllowlist` for the host they call. Without it
  they got `ERR_HOST_NOT_ALLOWED` — the allow-list correctly refusing before any
  payment, which is worth knowing works.

Verified: `tsc --noEmit` silent, `tests/mcp.test.ts` 37 passing, and
`tests/openapi.test.ts` and `src/__tests__/pairIssuerMatch.test.ts` pass on
their own. `tests/aggregator.property.test.ts` is a pre-existing fast-check
flake that passes in isolation and on `main`.

Closes #406

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Miracle656 added a commit that referenced this pull request Oct 21, 2026
The per-call cap bounded how much one mistake could cost; it did not bound a
day. A caller in a retry loop could pay it again and again without limit. There
is now a cumulative ceiling, and the design decisions behind it are the right
ones for money:

- **It survives a restart.** The running total is persisted and reloaded, so
  restarting the server does not refill the budget. An in-memory counter would
  have made the ceiling advisory on any host that recycles processes.
- **It fails closed.** An unreadable or corrupt state file is treated as fully
  spent, not as a clean slate, and the server says to fix the file and restart.
  That is the opposite of the right default for a rate limiter and exactly
  right for a spend cap.
- **It writes atomically** — temp file then rename — so a crash mid-write
  cannot leave a truncated total that reads as a smaller spend.
- **Money is exact integer nanodollars**, parsed from strings, with more than
  nine decimal places rejected rather than rounded.
- **A rejected payment is not charged**, and a call whose worst case no longer
  fits is refused rather than allowed to overspend the remainder.

Six resolutions while merging. The branch was 47 commits behind and both sides
had restructured the same class, so git auto-merged several regions into
something that looked clean and did not compile:

- Two constructors were spliced together — this PR's `constructor(config)` ended
  up inside `main`'s field declarations. Rebuilt as one class with all fields.
- An inserted `budget?: SpendBudget` field swallowed the opening `/**` of the
  next doc comment.
- The `paid_call` description was already broken **on the PR branch**: a `),`
  after the budget ternary terminated the string and left the rest as a bare
  expression in an object literal. Concatenated properly.
- Kept `main`'s `AbortSignal.timeout` on the paid fetch (#525), which this
  branch predates, and `classifyPaidCallError` (#480) alongside the new
  `SpendBudget` import.
- The `payment_budget` tool is now pushed conditionally in `main`'s tools-array
  shape rather than spread into a literal that no longer exists.
- `tests/mcp.test.ts` would have dropped 19 tests that landed on `main` after
  this branched. Kept `main`'s file and appended the budget suite: 46 tests.
  Its harness also needed `paidCallAllowlist` — the allow-list (#404) is checked
  before the budget, so every case was failing with ERR_HOST_NOT_ALLOWED and
  never reaching the ceiling — plus the `ToolSchema` import.

Verified on the merge result: `tsc --noEmit` silent, 85 suites pass, nothing red.

Closes #405

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Miracle656 added a commit that referenced this pull request Oct 21, 2026
Twice in one day a branch removed test cases that existed on `main`, and git
reported no conflict either time.

PR #480 dropped 19 — the Zod argument suite from #555 and the upstream deadline
suite from #525. PR #492 would have dropped the same 19. Both branches were
opened before those landed, so merging `main` in replaced `tests/mcp.test.ts`
wholesale rather than merging it: the result is the older file plus the
author's additions, and everything added in between is gone. Both PRs reported
"mergeable". Both typechecked. The only way I noticed was counting `it(` on
each side, by hand, twice.

`scripts/check-dropped-tests.mjs` compares the base against the tree a merge
would actually produce — not against the branch tip, which over-reports wildly
(a branch 47 commits behind is "missing" every case added since it forked,
merged or not). It lists the cases by file and name and exits 1.

It does not run the tests and does not judge whether a removal is right.
Removing a test is sometimes exactly correct — the feature went away, the case
was a duplicate, the assertion was wrong. The point is that the removal is
deliberate rather than accidental: say so in the PR and apply
`tests-removed-intentionally`.

Validated both ways before committing, since a guard nobody has seen fire is an
assumption: a branch with a case deleted exits 1 and names it; an unchanged
branch exits 0.

Known limitation, written down rather than left to be discovered: when the
merge conflicts, `git merge-tree` writes a tree containing both sides, so no
case looks missing and the check passes. That is the case a human already has
to resolve by hand — which is exactly where #492's loss would have happened.
The check is strongest for the silent case, which is the one that bit.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The MCP server's upstream call has no deadline and no error handling

3 participants